<?php
/*
 * Copyright (c) 2013 Eugene Kuzmenko
 *
 * Permission  is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in  the Software without restriction, including without limitation the rights
 * to  use,  copy,  modify,  merge, publish, distribute, sublicense, and/or sell
 * copies  of  the  Software,  and  to  permit  persons  to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The  above  copyright  notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE  SOFTWARE  IS  PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED,  INCLUDING  BUT  NOT  LIMITED  TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS  FOR  A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS  OR  COPYRIGHT  HOLDERS  BE  LIABLE  FOR  ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 *
 * For more information, see <http://www.aljey.com>.
 */

namespace Al\Doctrine\Lib;

use Al\Doctrine\Exception\MappingException,
    Doctrine\Common\EventManager,
    Doctrine\DBAL\DriverManager,
    Doctrine\ORM\EntityManager,
    Doctrine\ORM\Mapping\Driver\DatabaseDriver,
    Doctrine\ORM\Tools\DisconnectedClassMetadataFactory,
    Doctrine\ORM\Tools\EntityGenerator,
    Doctrine\ORM\Tools\EntityRepositoryGenerator,
    Doctrine\ORM\Tools\SchemaTool,
    Doctrine\ORM\Tools\Setup,
    Al\Core\Lib\Service,
    Doctrine\Common\DataFixtures\Loader,
    Doctrine\Common\DataFixtures\Executor\ORMExecutor,
    Doctrine\Common\DataFixtures\Purger\ORMPurger,
    Doctrine\Common\Util\Inflector;

/**
 * The AlWEB wrapper around the Doctrine 2 ORM entity manager.
 *
 * @author Eugene Kuzmenko <mail@aljey.com>
 */
class Em extends EntityManager implements \ArrayAccess {

  /**
   * @var DisconnectedClassMetadataFactory {@inheritDoc}
   */
  private $disconnectedCMF;

  /**
   * @var \Doctrine\ORM\Query {@inheritDoc}
   */
  private $dqlQuery;

  /**
   * @var EntityGenerator {@inheritDoc}
   */
  private $entityGenerator;

  /**
   * @var YamlExporter {@inheritDoc}
   */
  private $exporter;

  /**
   * @var EntityRepositoryGenerator {@inheritDoc}
   */
  private $repositoryGenerator;

  /**
   * @var SchemaTool {@inheritDoc}
   */
  private $tool;

  /** @inheritDoc */
  public function __construct() {
    $config = Setup::createConfiguration(true);
    $config->setMetadataDriverImpl(new YamlDriver);
    $conn = DriverManager::getConnection(Service::get('config.params.database',
      array()) + array(
        'driver' => 'pdo_mysql',
        'host' => 'localhost',
        'user' => 'root',
        'password' => '',
      ),
      $config, new EventManager);
    parent::__construct($conn, $config, $conn->getEventManager());
    $this->getEventManager()->addEventSubscriber(new EventSubscriber);
  }

  /**
   * Forces the factory to load the metadata of all classes known to the
   * underlying mapping driver.
   *
   * @return array The ClassMetadata instances of all mapped classes.
   */
  private function getMeta() {
    return $this->getMetadataFactory()->getAllMetadata();
  }

  /**
   * Retrieves ClassMetadataInfo objects without requiring the entity class to
   * actually exist. This allows us to load some mapping information and use it
   * to do things like generate code from the mapping configuration.
   *
   * @return array The ClassMetadata instances of all mapped classes.
   */
  private function getDisconnectedMeta() {
    if (!$this->disconnectedCMF) {
      $cmf = new DisconnectedClassMetadataFactory;
      $cmf->setEntityManager($this);
      $this->disconnectedCMF = $cmf;
    }
    return $this->disconnectedCMF->getAllMetadata();
  }

  /**
   * Returns a SchemaTool instance that uses the connection of the entity
   * manager.
   *
   * @return SchemaTool {@inheritDoc}
   */
  private function getTool() {
    if (!$this->tool) {
      $this->tool = new SchemaTool($this);
    }
    return $this->tool;
  }

  /**
   * Retrieves db mapping information, without the need for any configuration
   * files.
   *
   * @return array
   * @throws \Al\Doctrine\Exception\MappingException
   */
  public function getDBMeta() {
    $config = $this->getConfiguration();
    $driver = $config->getMetadataDriverImpl();
    $config->setMetadataDriverImpl(new DatabaseDriver($this->getConnection()
      ->getSchemaManager()));
    $metadata = $this->getDisconnectedMeta();
    $config->setMetadataDriverImpl($driver);
    if (!$metadata) {
      throw new MappingException('The currently configured database does not ' .
        'contain any mapping information.');
    }
    return $metadata;
  }

  /**
   * Returns the full class name of an entity.
   *
   * @param Object|string $entity Entity instance or a short name in the form
   *                              Namespace\Plugin:Entity
   * @return string
   */
  public function entityLongClass($entity) {
    if (!is_string($entity)) $entity = get_class($entity);
    $ns = explode(':', $entity);
    return (2 === count($ns)) ? "$ns[0]\\Model\\$ns[1]\\$ns[1]" : $entity;
  }

  /**
   * Returns the shortened version of the class name of an entity.
   *
   * @param Object|string $entity Entity instance or a long name in the form
   *                              Namespace\Plugin\Model\Entity\Entity
   * @return string
   */
  public function entityShortClass($entity) {
    if (!is_string($entity)) $entity = get_class($entity);
    if (false !== strpos($entity, ':')) return $entity;
    return preg_replace('/\\\Model\\\(\w+)\\\(\1)$/', ':$2', $entity);
  }

  /**
   * Clears the entire ORM cache.
   */
  public function clearCache() {
    $config = $this->getConfiguration();
    array_map(function (&$driver) {
      $driver->deleteAll();
      $driver->flushAll();
    }, array(
      $config->getMetadataCacheImpl(),
      $config->getResultCacheImpl(),
      $config->getQueryCacheImpl()
    ));
  }

  /**
   * Creates the database.
   */
  public function createDatabase() {
    $params = $this->getConnection()->getParams();
    $name = isset($params['path']) ? $params['path'] : $params['dbname'];
    unset($params['dbname']);
    $tmpConnection = DriverManager::getConnection($params);
    if (!isset($params['path'])) {
      $name = $tmpConnection->getDatabasePlatform()
        ->quoteSingleIdentifier($name);
    }
    $tmpConnection->getSchemaManager()->createDatabase($name);
    $tmpConnection->close();
  }

  /**
   * Drops the database.
   */
  public function dropDatabase() {
    $connection = $this->getConnection();
    $params = $connection->getParams();
    $name = isset($params['path']) ? $params['path'] : $params['dbname'];
    if (!isset($params['path'])) {
      $name = $connection->getDatabasePlatform()->quoteSingleIdentifier($name);
    }
    $connection->getSchemaManager()->dropDatabase($name);
  }

  /**
   * Creates schema.
   */
  public function createSchema() {
    $this->getTool()->createSchema($this->getMeta());
  }

  /**
   * Updates schema.
   */
  public function updateSchema() {
    $this->getTool()->updateSchema($this->getMeta());
  }

  /**
   * Drops schema.
   */
  public function dropSchema() {
    $this->getTool()->dropDatabase();
  }

  /**
   * Generates entities and repositories based on the current mapping
   * configuration.
   */
  public function generateEntities() {
    $autoload = Service::get('autoload');
    if (!$this->entityGenerator) {
      $this->entityGenerator = new EntityGenerator;
      $this->entityGenerator->setGenerateAnnotations(false);
      $this->entityGenerator->setGenerateStubMethods(true);
      $this->entityGenerator->setRegenerateEntityIfExists(false);
      $this->entityGenerator->setUpdateEntityIfExists(true);
      $this->entityGenerator->setNumSpaces(2);
      $this->entityGenerator->setBackupExisting(false);
    }
    if (!$this->repositoryGenerator) {
      $this->repositoryGenerator = new EntityRepositoryGenerator;
    }
    $autoload->silentFail = true;
    foreach ($this->getDisconnectedMeta() as $meta) {
      @$this->entityGenerator->writeEntityClass($meta, dirname($autoload
        ->find($meta->name)) . str_repeat("/..",
        count(explode('\\', $meta->name)) - 1));
      if ($meta->customRepositoryClassName) {
          @$this->repositoryGenerator->writeEntityRepositoryClass($meta
            ->customRepositoryClassName, dirname($autoload->find($meta
              ->customRepositoryClassName)) . str_repeat("/..",
            count(explode('\\', $meta->customRepositoryClassName)) - 1));
      }
    }
    $autoload->silentFail = false;
  }

  /**
   * Translates the current database structure into the mapping configuration.
   *
   * Example:
   * <code>
   * Em::getInstance()->importMapping('Acme\Blog', array('BlogEntry'));
   * </code>
   *
   * @param string $plugin The name of a registered plugin (eg "Acme\Blog").
   * @param array $tables [optional] An array of table names, if an empty array
   *                      is supplied or is omitted all the tables will be
   *                      imported.
   * @throws \InvalidArgumentException
   * @throws MappingException
   */
  public function importMapping($plugin, $tables = array()) {
    $config = Service::get('config');
    if (!($location = $config->getPluginLocation($plugin))) {
      throw new \InvalidArgumentException(
        'Missing required argument "plugin".');
    }
    if (!$this->exporter) {
      $this->exporter = new YamlExporter;
      $this->exporter->setOverwriteExistingFiles(true);
    }
    $all = !count($tables);
    $metadata = $this->getDBMeta();
    @mkdir($dir = "$location/Resources/config/mapping", 0777, true);
    $mapping = @file_get_contents($config_file =
      "$location/Resources/config/config.yml");
    if (false === $mapping) $mapping = '';
    foreach ($metadata as &$meta) {
      if ($all || in_array($meta->name, $tables, true)) {
        $class = $meta->name;
        $meta->name = "$plugin\\Model\\$class\\$class";
        $meta->customRepositoryClassName = $meta->name . 'Repository';
        file_put_contents($file = "$dir/$class.yml", $this->exporter
          ->exportClassMetadata($meta));
        $mapping = $config->updateConfig(
          $mapping,
          'mapping',
          "#import mapping/$class.yml"
        );
      }
    }
    file_put_contents($config_file, $mapping);
  }

  /**
   * Runs a DQL query on the database.
   *
   * @param string $dql A DQL query string.
   * @param array $params [optional] An array of parameters (specified within
   *                      the query string starting with a colon).
   * @return array Query result.
   * @throws \InvalidArgumentException
   */
  public function runDQL($dql, $params = array()) {
    if (!$dql) {
      throw new \InvalidArgumentException('Missing required argument "$dql".');
    }
    if (!$this->dqlQuery) {
      $this->dqlQuery = $this->createQuery();
    }
    return $this->dqlQuery->setDql($dql)->setParameters($params)->getResult();
  }

  /**
   * Runs an SQL query on the database.
   *
   * @param string $sql An SQL query string.
   * @param array [optional] $params The query parameters.
   * @return array Query result.
   * @throws \InvalidArgumentException
   */
  public function runSQL($sql, $params = array()) {
    if (!$sql) {
      throw new \InvalidArgumentException('Missing required argument "$sql".');
    }
    $connection = $this->getConnection();
    if (stripos($sql, 'select') === 0) {
      return $connection->fetchAll($sql, $params);
    }
    return $connection->executeUpdate($sql, $params);
  }

  /**
   * Loads data fixtures.
   */
  public function loadFixtures() {
    $loader = new Loader;
    foreach (Service::get('config.plugin_list') as $path) {
      if (is_dir($dir = "$path/Fixture")) $loader->loadFromDirectory($dir);
    }
    $executor = new ORMExecutor($this, new ORMPurger);
    $executor->execute($loader->getFixtures());
  }

  /**
   * Generates the crud controller.
   *
   * @param string $plugin A plugin name.
   * @param string $entity An entity name.
   * @return string
   */
  public function generateCrud($plugin, $entity) {
    $entity = $this->entityLongClass($entity);
    $service = Service::get();
    $config = $service['config'];
    $template = $service['template'];
    $doctrine_config = $config['plugins']['Al\Doctrine'];
    $plugin_dir = $config->getPluginLocation($plugin);
    $meta = $this->getClassMetadata($entity);
    $class = $meta->getReflectionClass()->getShortName();
    $lcClass = lcfirst($class);
    $form_class = "{$class}Form";
    $list_template = "list_{$lcClass}_records";
    $read_template = "read_{$lcClass}_records";
    $form_template = "form_{$lcClass}_records";
    @mkdir($dir = "$plugin_dir/Controller", 0777, true);
    $result = $template->render($doctrine_config['crud'], array(
      'plugin' => $plugin,
      'class' => $class,
      'list_template' => $list_template,
      'read_template' => $read_template,
      'form_template' => $form_template,
      'form_class' => $form_class,
    ));
    file_put_contents("$dir/{$class}Controller.php", $result[0]);
    $dir = "$plugin_dir/Resources/config";
    $yaml = $config->updateConfig(
      $original = @file_get_contents($location = "$dir/config.yml"),
      'routes',
      '#import routes.yml'
    );
    $yaml = $config->updateConfig(
      $yaml,
      'plugins',
      "$plugin:"
    );
    $yaml = $config->updateConfig(
      $yaml,
      $plugin_config = str_replace('\\', '\\\\', $plugin),
      "$list_template: $plugin:$lcClass:list.html.smarty"
    );
    $yaml = $config->updateConfig(
      $yaml,
      $plugin_config,
      "$read_template: $plugin:$lcClass:read.html.smarty"
    );
    $yaml = $config->updateConfig(
      $yaml,
      $plugin_config,
      "$form_template: $plugin:$lcClass:form.html.smarty"
    );
    if ($yaml != $original) {
      @mkdir($dir, 0777, true);
      file_put_contents($location, $yaml);
    }
    $yaml = $config->updateConfig(
      $original = @file_get_contents($location = "$dir/routes.yml"),
      $list_route = ($route = strtolower(str_replace('\\', '_', $plugin) .
          "_$class")) . '_list', "path: \"{_locale}/$lcClass\""
    );
    $yaml = $config->updateConfig(
      $yaml,
      $list_route,
      "controller: $plugin:$lcClass:list"
    );
    $yaml = $config->updateConfig(
      $yaml,
      $read_route = $route . '_read',
      "path: \"{_locale}/$lcClass/{id}\""
    );
    $yaml = $config->updateConfig(
      $yaml,
      $read_route,
      "controller: $plugin:$lcClass:read"
    );
    $yaml = $config->updateConfig(
      $yaml,
      $read_route,
      "requirements: { id: \\d+ }"
    );
    $yaml = $config->updateConfig(
      $yaml,
      $form_route = $route . '_form',
      "path: \"{_locale}/$lcClass/form\""
    );
    $yaml = $config->updateConfig(
      $yaml,
      $form_route,
      "controller: $plugin:$lcClass:form"
    );
    $yaml = $config->updateConfig(
      $yaml,
      $form_route,
      "method: post"
    );
    $yaml = $config->updateConfig(
      $yaml,
      $form_display_route = $form_route . '_display',
      "path: \"{_locale}/$lcClass/form\""
    );
    $yaml = $config->updateConfig(
      $yaml,
      $form_display_route,
      "controller: $plugin:$lcClass:formDisplay"
    );
    if ($yaml != $original) {
      @mkdir($dir, 0777, true);
      file_put_contents($location, $yaml);
    }
    $fields = $config['mapping'][$entity]['fields'];
    $columns = array();
    foreach ($this->getSettableColumns($entity) as $column) {
      $field = $fields[$column];
      $editor = isset($field['editor']) ? $field['editor'] : array();
      if (!is_array($editor)) $editor = array('type' => $editor);
      if (isset($field['type'])) {
        switch ($field['type']) {
          case 'integer':
          case 'smallint':
          case 'bigint':
            $editor += array('type' => 'number');
            break;
          case 'boolean':
            $editor += array('type' => 'choice', 'expanded' => true);
            break;
          case 'decimal':
          case 'float':
            $editor += array('type' => 'number', 'decimal' => true);
            break;
          case 'date':
            $editor += array('type' => 'date');
            break;
          case 'time':
            $editor += array('type' => 'time');
            break;
          case 'datetime':
            $editor += array('type' => 'datetime');
            break;
          case 'datetimetz':
            $editor += array('type' => 'datetime-local');
            break;
          case 'text':
            $editor += array('type' => 'textarea');
            break;
          case 'array':
          case 'simple_array':
          case 'json_array':
            $editor += array('type' => 'choice');
        }
      }
      if ($meta->isNullable($column)) $editor += array('required' => false);
      $columns[$column] = $editor + array(
        'label' => ucwords(str_replace('_', ' ', $column)),
      );
    }
    foreach ($meta->getAssociationNames() as $assoc) {
      $columns[$assoc] = array(
        'type' => 'choice',
        'label' => ucwords(str_replace('_', ' ', $assoc)),
        'assoc' => $this->entityShortClass($meta
          ->getAssociationTargetClass($assoc)),
      );
    }
    @mkdir($dir = "$plugin_dir/Form", 0777, true);
    $result = $template->render($doctrine_config['form_class'], array(
      'plugin' => $plugin,
      'class' => $class,
      'form_class' => $form_class,
      'form_route' => $form_route,
      'columns' => str_replace("\n" , "\n  ", preg_replace('/=>\s*/m', '=> ',
        var_export($columns, true))),
    ));
    file_put_contents("$dir/$form_class.php", $result[0]);
    $columns = $this->getGettableColumns($entity);
    @mkdir($dir = "$plugin_dir/Resources/view/$lcClass", 0777, true);
    $result = $template->render($doctrine_config['list'], array(
      'class' => $class,
      'columns' => $columns,
      'entity' => $entity,
      'read_route' => $read_route,
      'form_display_route' => $form_display_route,
    ));
    file_put_contents("$dir/list.html.smarty", $result[0]);
    $result = $template->render($doctrine_config['read'], array(
      'class' => $class,
      'columns' => $columns,
      'entity' => $entity,
      'list_route' => $list_route,
    ));
    file_put_contents("$dir/read.html.smarty", $result[0]);
    $result = $template->render($doctrine_config['form'], array(
      'class' => $class,
    ));
    file_put_contents("$dir/form.html.smarty", $result[0]);
    /** @noinspection PhpToStringImplementationInspection */
    return '/'. Service::get('http.session._locale') . "/$lcClass";
  }

  /** @inheritDoc */
  public function getRepository($entity) {
    return parent::getRepository($this->entityLongClass($entity));
  }

  /**
   * Retrieves an array of column names that the model class allows to read from
   *
   * @param string $entity
   * @return array
   */
  public function getGettableColumns($entity) {
    return $this->filterColumns($entity, function ($column, $props, $meths) {
      if (in_array($column, $props, true)) return true;
      $classified = Inflector::classify($column);
      return in_array("get$classified", $meths, true) ||
        in_array("is$classified", $meths, true);
    });
  }

  /**
   * Retrieves an array of column names that the model class allows to write to.
   *
   * @param string $entity
   * @return array
   */
  public function getSettableColumns($entity) {
    return $this->filterColumns($entity, function ($column, $props, $meths) {
      return in_array($column, $props, true) ||
        in_array('set' . Inflector::classify($column), $meths, true);
    });
  }

  /**
   * Filters entity columns based on the callback response.
   *
   * @param string $entity
   * @param callable $callback
   * @return array
   */
  private function filterColumns($entity, $callback) {
    $entity = $this->entityLongClass($entity);
    $props = $this->getPublicProperties($entity);
    $meths = $this->getPublicMethods($entity);
    $response = array();
    foreach ($this->getClassMetadata($entity)->getColumnNames() as $column) {
      if ($callback($column, $props, $meths)) $response[] = $column;
    }
    return $response;
  }

  /**
   * Returns the names of all the public properties of an entity.
   *
   * @param string $entity
   * @return array
   */
  public function getPublicProperties($entity) {
    return array_map(function ($item) {
      return $item->getName();
    }, $this->getClassMetadata($this->entityLongClass($entity))
      ->getReflectionClass()->getProperties(\ReflectionProperty::IS_PUBLIC));
  }

  /**
   * Returns the names of all the public methods of an entity.
   *
   * @param string $entity
   * @return array
   */
  public function getPublicMethods($entity) {
    return array_map(function ($item) {
      return $item->getName();
    }, $this->getClassMetadata($this->entityLongClass($entity))
      ->getReflectionClass()->getMethods(\ReflectionMethod::IS_PUBLIC));
  }

  /**
   * Returns the names of all the setters of an entity.
   *
   * @param string $entity
   * @return array
   */
  public function getSetters($entity) {
    return array_filter($this->getPublicMethods($entity), function ($item) {
      return preg_match('/^set/', $item);
    });
  }

  /**
   * Returns the names of all the getters of an entity.
   *
   * @param string $entity
   * @return array
   */
  public function getGetters($entity) {
    return array_filter($this->getPublicMethods($entity), function ($item) {
      return preg_match('/^get/', $item) || preg_match('/^is/', $item);
    });
  }

  /**
   * Gets a value from an entity object by name.
   *
   * @param Object $entity
   * @param string $prop
   * @return mixed
   */
  public function entityGet($entity, $prop) {
    if (in_array($prop, $this->getPublicProperties($entity), true)) {
      return $entity->$prop;
    }
    $meths = $this->getPublicMethods($entity);
    $classified = Inflector::classify($prop);
    if (in_array($meth = "get$classified", $meths, true) ||
        in_array($meth = "is$classified", $meths, true)) {
      return $entity->$meth();
    }
  }

  /**
   * Sets a value into an entity object by name.
   *
   * @param Object $entity
   * @param string $prop
   * @param mixed $value [optional]
   */
  public function entitySet($entity, $prop, $value = null) {
    if (in_array($prop, $this->getPublicProperties($entity), true)) {
      $entity->$prop = $value;
    } elseif (in_array($meth = 'set' . Inflector::classify($prop),
        $this->getPublicMethods($entity), true)) {
      $entity->$meth($value);
    }
  }

  /**
   * Optimistically returns true to minimize the number of disk reads.
   * @inheritDoc
   */
  public function offsetExists($offset) { return true; }

  /** @inheritDoc */
  public function offsetGet($offset) { return $this->getRepository($offset); }

  /** @inheritDoc */
  public function offsetSet($offset, $value) {
    throw new \OverflowException('You cannot create a repository like that');
  }

  /** @inheritDoc */
  public function offsetUnset($offset) {
    throw new \UnderflowException('You cannot remove a repository');
  }

}